Skip to content

Backup browser-driven address resolver (free USPS standby)#3

Merged
ak2k merged 1 commit into
mainfrom
feat/web-address-resolver
Jun 3, 2026
Merged

Backup browser-driven address resolver (free USPS standby)#3
ak2k merged 1 commit into
mainfrom
feat/web-address-resolver

Conversation

@ak2k

@ak2k ak2k commented Jun 2, 2026

Copy link
Copy Markdown
Owner

Why

Address standardization normally goes through the official apis.usps.com Addresses API (NewApiClient). USPS is moving that API to a paid, licensed tier on 2026-07-12 (the legacy free Web Tools API was already retired 2026-01-25). This adds a $0 standby so /generate keeps producing full ZIP+4 + 11-digit-routing barcodes when the API is unavailable.

How

USPS's free consumer ZIP-lookup (tools.usps.com/.../ziplookup/zipByAddress) returns everything we need (zip5/zip4/deliveryPoint/dpvConfirmation) but sits behind Akamai Bot Manager. Empirically, a live browser running Akamai's JS sensor in-request is mandatory — vanilla curl, curl_cffi with exact firefox135 TLS impersonation, and even curl_cffi carrying a browser-minted _abck cookie + refreshed bm_* tokens are all blocked on the data XHR (the static page passes; the API XHR does not). So the resolver uses nodriver + Chromium, headful under xvfb.

It's a standby, not a replacement:

  • mailwatch/web_lookup.py — pure parse_address_list() (USPS web JSON → existing models) behind a lazy-imported nodriver driver. Engine isolated to one function; swappable (e.g. Camoufox) without touching callers.
  • mailwatch/cli_resolve.py — seeds address_cache keyed identically to what /generate computes (db.hash_address_dict of the raw AddressRequest), so the service and its request path are unchanged — they just get a cache hit and skip the API. The browser never runs in the gunicorn workers.
  • nodriver lives in an optional browser dependency group (not main deps).
  • apps.resolve-address (Linux-only): wires pkgs.chromium + xvfb-run; deliberately not added to the NixOS service.

Usage

# On the host, with the SAME recipient fields you'd enter on /generate:
nix run .#resolve-address -- "475 L'Enfant Plaza SW" --city Washington --state DC --zip 20260

Verification

  • nix flake check -L green: lint, mypy (20 files), 276 tests, coverage 95.9%, nixfmt/statix/deadnix.
  • nodriver builds under uv2nix.
  • Unit tests cover the parser, the cache-value round-trip (StandardizedAddressResponse.model_validate_json), and cache-key alignment with the service's construction.
  • Integration test is opt-in (nix run .#test -- -m integration).
  • End-to-end on aarch64 NixOS against live USPS from a datacenter IP: 1600 PENNSYLVANIA AVE NW | 20500-0005 | dp=00 | dpv=Y.

Caveat

Automates a free public tool — a gray area in USPS's terms of use. It's a low-volume, cached fallback; keep volume low.

Add a standby address-standardization path for when apis.usps.com is
unavailable (e.g. after USPS's 2026-07-12 move of the Addresses API to a
paid, licensed tier).

mailwatch.web_lookup drives USPS's free consumer ZIP-lookup
(tools.usps.com) with nodriver + Chromium, headful under xvfb. That
endpoint is behind Akamai Bot Manager, which requires a live JS-sensor
browser session: plain HTTP clients — including TLS-fingerprint-
impersonating ones carrying a browser-minted _abck cookie — are blocked on
the data XHR.

mailwatch.cli_resolve seeds the address_cache keyed identically to what
/generate looks up, so the service and its request path are unchanged: they
just get a cache hit and skip the API. The browser stays out of the
hardened gunicorn workers entirely.

- web_lookup: pure parse_address_list (unit-tested off a captured payload)
  plus a lazy-imported nodriver driver behind a thin seam (engine swappable)
- cli_resolve: seeds address_cache; cache-key alignment unit-tested
- nodriver in an optional `browser` dependency group, not main deps
- apps.resolve-address (Linux): Chromium + xvfb-run wired; not in the service
- integration test is opt-in (-m integration)

Verified end-to-end on aarch64 NixOS against live USPS from a datacenter IP:
full ZIP+4 + delivery point returned.
@ak2k ak2k merged commit 207d43a into main Jun 3, 2026
4 checks passed
@ak2k ak2k deleted the feat/web-address-resolver branch June 3, 2026 13:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant